fix(service-analytics): compile the case-sensitive text family per SQL dialect, so a $contains read scope stops admitting rows it excludes on SQLite - #15790
Conversation
…alect Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ap; keep the fake driver's dialectName override public Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…alytics-like-case-exact
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3c5be1dad89e5773f8ae9d29e62949c9a9554936 && git checkout 3c5be1dad89e5773f8ae9d29e62949c9a9554936
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 901773b213b7848bdd60182db9afbdc55a7f9ad1 dbf91caa81d1d8e7625f276870ec7dceaee4c492 && git checkout -B drift-repro 901773b213b7848bdd60182db9afbdc55a7f9ad1 && git merge --no-ff dbf91caa81d1d8e7625f276870ec7dceaee4c492
node scripts/docs-audit/affected-docs.mjs --json 901773b213b7848bdd60182db9afbdc55a7f9ad1
|
Clause-② contract review — PR #15790 (card #15684) — PASS, with notesHead Already verified by the PM and not redone: head match, 13 files (+1010/−102), 1. "No dialect-blind fix exists" — holds. MEASURED on sql.js 1.14.1 (SQLite 3.49.1) and better-sqlite3 13.0.3 (SQLite 3.53.4); MySQL/PG halves reasoned, NOT MEASUREDOver the shared nine-row fixture, contract
Finding (doc precision, not a design defect): the header's third bullet — " 2. Construct table arm for arm — matches. READ, both sources side by side
Escape classes: Two notes on the anti-drift claim, neither blocking: (a) the executed 3. GLOB escaping completeness — complete. READ + MEASUREDWhy 4. Non-regression controls — hold by READING; executed A/B NOT MEASUREDBy reading, the postgres/unknown arm ( 5. All three compilers — covered. READ
6. Visibility widening — READ
7. Honesty audit — MEASURED/READ
8. #15780 — READExists, open, labels 9. Structural vs contract member — a read, not a decisionThe dev's reasoning does not hold: optional VerdictPASS. Nothing here makes a published statement false or the fix wrong; NOT MEASURED by this review: the executed A/B byte-identity matrix and the local Reviewer: clause-② contract review, 🤖 Generated with Claude Code https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y Generated by Claude Code |
PM note on landing — two claims in this PR are narrower than they readPosted by the 1. The
|
| build | CAST(col AS BLOB) LIKE '%acme%' |
|---|---|
| better-sqlite3 13.0.3 (SQLite 3.53.4) | [] — because it compiles with SQLITE_LIKE_DOESNT_MATCH_BLOBS |
| sql.js 1.14.1 (SQLite 3.49.1) | ['1','2'] — the folded rows, i.e. exactly the defect |
⇒ The rejection of that candidate still holds, and for a stronger reason than the header gives: one construct with two different meanings across the two SQLite builds this repo ships is disqualifying on its own. Only the "returns nothing" phrasing is too broad.
driver-sql's #6518 header — this PR inherited it rather than introducing it. Filed as a docs-precision card so a future editor of either file inherits the measurement instead of rediscovering it.
2. The anti-drift precedent is overstated in the new test's header
The new suite's header attributes an executed-driver cross-check precedent to like-metacharacter-escape.test.ts. That test is a mirrored regex in a test, not an executed driver cross-check. ⇒ The PR body's wording ("a test, not a comment") is accurate; only the test header overstates it. The cross-check this PR actually performs — against a real SqliteWasmDriver — is genuine, and it mechanically holds only the sqlite arm; the mysql and postgres arms are held by two independent text pins, which match by reading today.
3. A correction to this PR's own NOT-MEASURED justification
The PR justifies not measuring MySQL/Postgres as "the same declared skip driver-sql's own #6518 suite records". That precedent is MySQL only — driver-sql's #6518 suite ran live Postgres 16.13. ⇒ The Postgres non-regression here rests instead on byte-identity (the postgres and no-hook arms emitting byte-identical SQL and params to pre-fix), which the review judged the right basis.
4. On the architectural question — right recommendation, wrong reason
The dev argued the dialect should stay a structural read because a dialect is a property of the SqlDriver family, not of every driver, and a contract member the memory/mongo drivers could only answer undefined to would declare a capability the platform lacks. ⇒ That reasoning does not hold: an optional IDataDriver member is precisely how family-specific capability is already modelled here — temporalFilterColumnSql is exactly that, a contract member since ADR-0053 D-A2.
The recommendation (leave it structural for now) nonetheless stands, on a different basis: the #11833 / #12248 evidence bar for promoting a seam, and D-A2's own tracked-interim history. ⇒ Non-blocking ask for whoever owns this next: record the promotion trigger somewhere findable, so "when a second consumer appears" is a condition someone will actually notice rather than a sentence in a PR.
⭐ Worth stating plainly, since it was the highest risk I flagged into this review: GLOB escaping was proven complete, not reasoned — 46 comparands × 3 shapes = 138 oracle checks, 0 mismatches, 0 errors, with the unescaped controls reproducing (*a*b*→6 rows, *a?b*→5, *a[b*→0). And the construct table does match driver-sql's arm for arm, read side by side.
Generated by Claude Code
Fixes #15684
service-analytics' three SQL compilers emittedcol LIKE ? ESCAPE ?for the case-SENSITIVE text family on every dialect. SQLite'sLIKEfolds ASCII case unconditionally, so on a SQLite datasource the query's ownwhere, the ADR-0021 D-C read scope and the/analytics/sqlecho all answered rows the #4706 Q2 = A contract excludes. Measured on sql.js over the sharedFILTER_TEXT_ROWSfixture:{ name: { $contains: 'acme' } }returned['1','2']—ACME Corpandacme corp— whereFILTER_TEXT_CASESsays['2']. On the read scope that is over-reach (#3948), the same readingread-scope-sql.tsalready applied to its own LIKE escaping (#5567).Head sha for every number below:
dbf91caa8.How the same semantics were reached without a driver dependency
The card asked this explicitly, so here is what was established rather than assumed.
1. A dialect-blind fix does not exist. Four candidates were checked and all fail:
GLOBeverywhereCAST(… AS BINARY)everywhereCAST(col AS BLOB) LIKE ?replace()LENGTHis bytes on MySQL;right()does not exist on SQLite; a negativesubstrstart is not portable to Postgres)So the dialect had to become an input — which is exactly the remedy
like-pattern.ts's own header predicted ("two things would have to arrive together: a dialect input reaching these three compilers, and the per-dialect construct table").2. The dialect arrives through the tier this package already uses for questions its compilers cannot answer.
DatasetScopedStrategyContext.sqlDialect, declared beside #14079'sdeclaredFieldTypeand tiered identically — a host that answers nothing keeps theLIKEit always had ("cannot answer, do not block"). It is filled byAnalyticsServicePluginfromIDataEngine.getDriverForObject, i.e. from the driver that will actually execute the statement, so no second dialect-resolution table exists to drift behind the driver's own knex spellings.3. No fourth spelling, and no driver dependency.
text-match-sql.tsre-emits #6518's construct table arm for arm through a caller-suppliedbindcallback, becausetextMatchPredicateis module-private, returns knex bindings, and lives in a package a service must not depend on. That is the identical arrangementlike-pattern.tsalready documents forescapeLikePatternversusapplyLike— and the anti-drift mechanism is the same one: a test, not a comment.text-operator-case-exactness.test.tsruns the sameFILTER_TEXT_CASESrows through a realSqliteWasmDriver(a devDependency, never a runtime one) on the same engine and requires the same row sets from both faces.No runtime driver dependency was added.
service-analytics'dependenciesare unchanged:@objectstack/core,@objectstack/spec,@objectstack/types.The one cross-package edit, named loudly
SqlDriver.dialectNamemoved fromprotectedtopublic(packages/drivers/driver-sql/src/sql-driver.ts). It is a derived, read-only getter and no behaviour moves; it is what lets the analytics plugin read the executing driver's own answer instead of re-deriving one. All three SQL drivers extendSqlDriver, sodriver-sql,driver-sqlite-wasm(which overridesisSqlite) anddriver-turso(knex clientbetter-sqlite3) all answer.That widening also forced a second one-line edit, and it is evidence the first was measured rather than assumed:
sql-driver-12732-varchar-emitter-parity-wiring.test.ts'sFakePostgresDriveroverrodedialectNameasprotected, and TypeScript refuses an override that narrows visibility.pnpm --filter @objectstack/driver-sql typecheckexited 2 on it (TS2415) before the override was madepublic override; it exits 0 after.The three compilers
NativeSQLStrategy.buildFilterClause— the executedwhere.compileScopedFilterToSql(read-scope-sql.ts) — the RLS read scope. NewReadScopeCompileOptions.dialect; both consumers (applyReadScope, the ObjectQL echo) fill it from the same hook, so scope and query cannot answer two row sets.ObjectQLStrategy.buildFilterClauseSql— the echo, which printedLIKEwhile the statement it claims to reproduce ran through a driver that has emittedGLOBon SQLite since drivers(sql family): 文本算子的大小写折叠是「方言的」而非「契约的」——$contains在 SQLite 过折叠、$icontains在 PG/MySQL 过折叠 #6518.Per dialect: sqlite
GLOB(one bound value, noESCAPEclause, its own*/?/[escaped class), mysqlLIKEoverCAST(… AS BINARY), postgresLIKEunchanged, unknownLIKEunchanged.The #14079 steer, reverted
text-operator-non-text-column.test.tshad steered one control comparand off the case axis (a.binstead ofacme) with a comment saying why: a plainLIKEfolds case on SQLite, soacmeanswered rows 1 AND 2 whether the disjunction worked or not. That suite now states its engine's dialect and the control is back on the case axis, answering row 2 alone — a strictly stronger control. ItsunawareCtxdeliberately keeps the dialect-blind configuration, because the coercion rows that file exists for were measured through it.Verification
pnpm --filter @objectstack/service-analytics test— 91 files, 1998 tests, 0 failures (exit 0).pnpm --filter @objectstack/service-analytics typecheck— exit 0;--listFilesconfirms the new test andtext-match-sql.tsare inside the program.typecheckexit 0 ondriver-sql,driver-sqlite-wasm,driver-turso; four targeteddriver-sqlsuites (the wiring test plus the three text/case ones) — 111 passed, 2 skipped, exit 0.pnpm lintrepo-wide (eslint . --no-inline-config) — exit 0. Not narrowed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(54 families named, identical before and after the merge oforigin/main). Run locally, every exit code captured by redirect before any pipe:check:nul-bytes,check:where-matcher,check:engine-double-contract,check:query-options-erasure,check:type-check-coverage,check:cross-package-test-inputs,check:test-source-alias,check:driver-conformance,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check:doc-authoring,check:published-files,check:objectql-double-limit,check:filter-alias-parity,check:dispatcher-error-vocabulary,check:type-source-resolution,check:logger-receiver-detach,check:partof-closing-keyword,check:single-claim-paths,check:error-status-conformance,check:error-code-casing— all exit 0.scripts/check-adr-0087-registration.mjsexit 0 with its--self-testcontrol also exit 0.check:published-readme-exportsexited 3 — its own output says nothing was measured, because ~37 packages have no builtdist;check:type-check-debtlikewise needs the whole workspace built. Both need a full-workspace build that CI's Build Core job performs. MySQL and live Postgres are NOT MEASURED: no server is provisionable in this container, so theCAST(… AS BINARY)arm and the Postgres non-regression are pinned as compiled TEXT, exactly the declared skipdriver-sql's own drivers(sql family): 文本算子的大小写折叠是「方言的」而非「契约的」——$contains在 SQLite 过折叠、$icontains在 PG/MySQL 过折叠 #6518 suite records.Every new pin was mutated to confirm it discriminates. Each leg committed first, mutated, proved on disk (a marker grep plus a
git hash-objectchange against the HEAD blob), run, then restored withgit checkout HEAD -- PATH(absolute) under anEXIT INT TERMtrap and proved restored (git diff HEADempty and the blob hash back to the HEAD one). No build ordistis involved: the compilers reach the tests through relative source imports, so vitest reads the mutated source directly.GLOBarmescapeGlobPatternto identity$icontainsonto the case-exact tableOut of scope, filed
#15780 — the same three compilers emit
translate()for$icontains, a function SQLite does not have (measured on sql.js 1.14.1:no such function: translate), so that statement fails to parse rather than answering wrong rows. Deliberately not addressed here: this card's scope is the case-EXACT four, and this PR's suite pins thetranslate()text as the control that must stay unchanged.like-pattern.tshad predicted it word for word; it is now measured, and the dialect table this PR adds is where the remedy belongs.Escaping (#5567) and the
$icontainsfold (#6520) are unchanged wherever aLIKEis still emitted, as the card required.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code